agentmux_srv\backend\storage/
history.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Agent session history — append-only per-agent entries with
5//! date-bucket + LIKE search.
6//!
7//! Extracted from `store.rs` in Phase R.4 of the storage
8//! modularization plan
9//! (`docs/specs/SPEC_STORE_MODULARIZATION_2026_05_27.md`). The
10//! method surface is unchanged — `Store::agent_history_*` still
11//! lives on `Store` via this `impl` block; callers stay on
12//! `storage::store::AgentHistory` thanks to the re-export.
13
14use rusqlite::params;
15use serde::{Deserialize, Serialize};
16
17use super::error::StoreError;
18use super::store::Store;
19
20/// An append-only session history entry for a agent definition.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct AgentHistory {
23    pub id: i64,
24    pub agent_id: String,
25    pub session_date: String,
26    pub entry: String,
27    pub timestamp: i64,
28}
29
30impl Store {
31    /// Append a history entry for an agent. Auto-sets session_date (today) and timestamp.
32    pub fn agent_history_append(
33        &self,
34        agent_id: &str,
35        entry: &str,
36    ) -> Result<AgentHistory, StoreError> {
37        let now = std::time::SystemTime::now()
38            .duration_since(std::time::UNIX_EPOCH)
39            .unwrap_or_default()
40            .as_millis() as i64;
41        // session_date as YYYY-MM-DD
42        let secs = (now / 1000) as u64;
43        let days = secs / 86400;
44        // Simple date calculation (no chrono dependency needed)
45        let session_date = format_epoch_date(days);
46        let conn = self.conn.lock().unwrap();
47        conn.execute(
48            "INSERT INTO db_agent_history (agent_id, session_date, entry, timestamp) VALUES (?1, ?2, ?3, ?4)",
49            params![agent_id, session_date, entry, now],
50        )?;
51        let id = conn.last_insert_rowid();
52        Ok(AgentHistory {
53            id,
54            agent_id: agent_id.to_string(),
55            session_date,
56            entry: entry.to_string(),
57            timestamp: now,
58        })
59    }
60
61    /// List history entries for an agent, with optional date filter and pagination.
62    pub fn agent_history_list(
63        &self,
64        agent_id: &str,
65        session_date: Option<&str>,
66        limit: i64,
67        offset: i64,
68    ) -> Result<Vec<AgentHistory>, StoreError> {
69        let conn = self.conn.lock().unwrap();
70        let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match session_date {
71            Some(date) => (
72                "SELECT id, agent_id, session_date, entry, timestamp
73                 FROM db_agent_history WHERE agent_id=?1 AND session_date=?2
74                 ORDER BY timestamp DESC LIMIT ?3 OFFSET ?4"
75                    .to_string(),
76                vec![
77                    Box::new(agent_id.to_string()),
78                    Box::new(date.to_string()),
79                    Box::new(limit),
80                    Box::new(offset),
81                ],
82            ),
83            None => (
84                "SELECT id, agent_id, session_date, entry, timestamp
85                 FROM db_agent_history WHERE agent_id=?1
86                 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3"
87                    .to_string(),
88                vec![
89                    Box::new(agent_id.to_string()),
90                    Box::new(limit),
91                    Box::new(offset),
92                ],
93            ),
94        };
95        let params_refs: Vec<&dyn rusqlite::types::ToSql> =
96            params_vec.iter().map(|b| b.as_ref()).collect();
97        let mut stmt = conn.prepare(&sql)?;
98        let rows = stmt.query_map(params_refs.as_slice(), |row| {
99            Ok(AgentHistory {
100                id: row.get(0)?,
101                agent_id: row.get(1)?,
102                session_date: row.get(2)?,
103                entry: row.get(3)?,
104                timestamp: row.get(4)?,
105            })
106        })?;
107        let mut entries = Vec::new();
108        for row in rows {
109            entries.push(row?);
110        }
111        Ok(entries)
112    }
113
114    /// Search history entries for an agent using LIKE-based matching.
115    pub fn agent_history_search(
116        &self,
117        agent_id: &str,
118        query: &str,
119        limit: i64,
120    ) -> Result<Vec<AgentHistory>, StoreError> {
121        let conn = self.conn.lock().unwrap();
122        let pattern = format!("%{}%", query);
123        let mut stmt = conn.prepare(
124            "SELECT id, agent_id, session_date, entry, timestamp
125             FROM db_agent_history WHERE agent_id=?1 AND entry LIKE ?2
126             ORDER BY timestamp DESC LIMIT ?3",
127        )?;
128        let rows = stmt.query_map(params![agent_id, pattern, limit], |row| {
129            Ok(AgentHistory {
130                id: row.get(0)?,
131                agent_id: row.get(1)?,
132                session_date: row.get(2)?,
133                entry: row.get(3)?,
134                timestamp: row.get(4)?,
135            })
136        })?;
137        let mut entries = Vec::new();
138        for row in rows {
139            entries.push(row?);
140        }
141        Ok(entries)
142    }
143}
144
145/// Format days-since-epoch as YYYY-MM-DD string.
146/// Simple implementation without chrono dependency.
147fn format_epoch_date(days_since_epoch: u64) -> String {
148    // Algorithm from https://howardhinnant.github.io/date_algorithms.html
149    let z = days_since_epoch + 719468;
150    let era = z / 146097;
151    let doe = z - era * 146097;
152    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
153    let y = yoe + era * 400;
154    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
155    let mp = (5 * doy + 2) / 153;
156    let d = doy - (153 * mp + 2) / 5 + 1;
157    let m = if mp < 10 { mp + 3 } else { mp - 9 };
158    let y = if m <= 2 { y + 1 } else { y };
159    format!("{:04}-{:02}-{:02}", y, m, d)
160}